Service: UAC-free upgrade via RunInstallerRequest#2007
Draft
tyrielv wants to merge 1 commit into
Draft
Conversation
bbb5073 to
6d2cd77
Compare
Add an upgrade flow that lets the user run `gvfs upgrade <installer-path> [--allow-unsigned]` from a non-elevated shell and have the already-elevated GVFS.Service launch the installer on the caller's behalf. Removes the UAC prompt that the old upgrade path required on every install. Builds on PR microsoft#1994 (merged) which added the versioned install layout and mount-process detection for Versions\ subdirs. How it works: - CLI: New UpgradeVerb sends a RunInstallerRequest over the existing GVFS.Service named pipe. Returns immediately after the service confirms the installer launched ("Upgrade started"). - Service: New RunInstallerHandler verifies the installer, then launches it detached with /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=<path>. Does NOT WaitForExit() — the installer stops GVFS.Service as part of its upgrade flow, so waiting would deadlock. - Staging: /STAGEIFMOUNTED=true is non-disruptive. With mounts active, the installer stages binaries to PendingUpgrade\ instead of unmounting. The existing PendingUpgradeHandler applies the staged upgrade on the next unmount. - Capability detection: Pre-2.1 services don't know the new RunInstallerRequest header and respond with "UnknownRequest". The verb detects this and emits a clear "your GVFS service is too old; install a newer GVFS first" error instead of a JSON deserialization stack trace. Clients can also probe the capability via `gvfs version` (>=2.1.x supports this flow). Security model — the service runs as LocalSystem and its named pipe is ACL'd for BUILTIN\Users, so the handler must assume the caller is untrusted: - Authenticode: Verified via WinVerifyTrust (WINTRUST_ACTION_GENERIC_VERIFY_V2) — the only Win32 API that actually checks the file's signed digest against its contents. Extracting just the signer cert (X509Certificate.CreateFromSignedFile + X509Chain.Build) is NOT sufficient: it leaves a tampered binary with an intact signature blob fully accepted. Maps the common HRESULTs (TRUST_E_NOSIGNATURE, TRUST_E_BAD_DIGEST, ...) to specific user-facing errors. - Publisher: Exact-string match against certificate.GetNameInfo(X509NameType.SimpleName) == "Microsoft Corporation". Avoids substring-collision attacks such as CN="Microsoft Corporation Ltd" or DNs that place the attacker's CN alongside "Microsoft Corporation" in other fields (a Subject.Contains check would accept either). - Product identity: PE ProductName must equal "VFS for Git", checked even when --allow-unsigned is in effect. Rejects other Microsoft-signed binaries (notepad.exe etc.) at the Authenticode-trusted but wrong-product stage. - --allow-unsigned: Available in DEBUG builds only. In release builds the CLI does not register the option and the service rejects AllowUnsigned=true requests outright (defense-in-depth against a hand-crafted pipe request bypassing the CLI). In debug builds the service additionally impersonates the pipe client (NamedPipeServerStream.RunAsClient, exposed via Connection.TryRunAsClient) and rejects unless the caller is in BUILTIN\Administrators. Without this gate, any local non-admin user could stamp ProductName="VFS for Git" onto an arbitrary binary and get LocalSystem code execution. - TOCTOU: The handler opens the installer with FileShare.Read (no SHARE_WRITE, no SHARE_DELETE) at the start of Run() and holds the handle across verify and Process.Start. On Windows this blocks any rename, delete, or write of the path, closing the window where an attacker could swap the file between verification and launch. Other notes: - GVFSJsonContext registers RunInstallerRequest and its Response for source-generated System.Text.Json serialization (NativeAOT-compatible). - RunInstallerHandler creates a fresh EventMetadata for each tracer call via CreateBaseMetadata(). JsonTracer mutates the passed-in metadata by adding the "Message" key, so a single shared instance would throw a duplicate-key exception on the second tracer call — and the throw was being caught and reported back as a false-positive "Upgrade failed" even though the installer had launched successfully. This matches the fresh-per-call pattern used by GetActiveRepoListHandler and RequestHandler. - Pipeline version bumped 2.0 -> 2.1 so the "service supports UAC-free upgrade" capability is detectable from the version number alone. Testing (manual end-to-end on Windows): - Unsigned dev installer without --allow-unsigned -> rejected ("Installer is not signed") - notepad.exe -> rejected by ProductName check - --allow-unsigned from non-admin shell (DEBUG build) -> rejected ("requires Administrator privileges") - Tampered Microsoft-signed installer (one byte flipped) -> rejected ("Authenticode hash does not match — file has been tampered with") - Real signed SetupGVFS.2.0.26147.6.exe with mounts active -> accepted, stages 23 files to PendingUpgrade\, mount status stays Ready - Unmount after staging -> PendingUpgradeHandler applies the staged upgrade within ~5s, version updates accordingly - 818 unit tests pass Assisted-by: Claude Opus 4.7 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
6d2cd77 to
1f55e7c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an upgrade flow that lets the user run
gvfs upgrade <installer-path>from a non-elevated shell and have the already-elevatedGVFS.Servicelaunch the installer on the caller's behalf. Removes the UAC prompt that the old upgrade path required on every install.Builds on #1994 (merged) which added the versioned install layout and mount-process detection for
Versions\subdirs.How it works
UpgradeVerbsends aRunInstallerRequestover the existingGVFS.Servicenamed pipe. Returns immediately after the service confirms the installer launched (Upgrade started).RunInstallerHandlerverifies the installer, then launches it detached with/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=<path>. Does NOTWaitForExit()— the installer stopsGVFS.Serviceas part of its upgrade flow, so waiting would deadlock./STAGEIFMOUNTED=trueis non-disruptive. With mounts active, the installer stages binaries toPendingUpgrade\instead of unmounting. The existingPendingUpgradeHandlerapplies the staged upgrade on the next unmount.RunInstallerRequestheader and respond with theUnknownRequestsentinel. The verb detects this and emits a clear "your GVFS service is too old; install a newer GVFS first" error instead of a JSON deserialization stack trace. Clients can also probe the capability viagvfs version(>=2.1.xsupports this flow).2.0→2.1so the capability is detectable from the version number alone.Security model
The service runs as
LocalSystemand its named pipe is ACL'd forBUILTIN\Users, so the handler must assume the caller is untrusted.WinVerifyTrust(WINTRUST_ACTION_GENERIC_VERIFY_V2) — the only Win32 API that actually checks the file's signed digest against its contents. Extracting just the signer cert (X509Certificate.CreateFromSignedFile+X509Chain.Build) is not sufficient: it leaves a tampered binary with an intact signature blob fully accepted. The common HRESULTs (TRUST_E_NOSIGNATURE,TRUST_E_BAD_DIGEST, ...) are mapped to specific user-facing errors.certificate.GetNameInfo(X509NameType.SimpleName)=="Microsoft Corporation". Avoids substring-collision attacks such asCN="Microsoft Corporation Ltd"or DNs that place the attacker's CN alongside"Microsoft Corporation"in other fields (aSubject.Containscheck would accept either).ProductNamemust equal"VFS for Git", checked even when--allow-unsignedis in effect. Rejects other Microsoft-signed binaries (e.g.notepad.exe) at the Authenticode-trusted-but-wrong-product stage.--allow-unsigned— Available inDEBUGbuilds only. In release builds the CLI does not register the option and the service rejectsAllowUnsigned=truerequests outright (defense-in-depth against a hand-crafted pipe request bypassing the CLI). In debug builds the service additionally impersonates the pipe client (NamedPipeServerStream.RunAsClient, exposed viaConnection.TryRunAsClient) and rejects unless the caller is inBUILTIN\Administrators. Without this gate, any local non-admin user could stampProductName="VFS for Git"onto an arbitrary binary and getLocalSystemcode execution.FileShare.Read(noSHARE_WRITE, noSHARE_DELETE) at the start ofRun()and holds the handle across verify andProcess.Start. On Windows this blocks any rename, delete, or write of the path, closing the window where an attacker could swap the file between verification and launch.Other notes
GVFSJsonContextregistersRunInstallerRequestand itsResponsefor source-generatedSystem.Text.Jsonserialization (NativeAOT-compatible).RunInstallerHandlercreates a freshEventMetadatafor each tracer call viaCreateBaseMetadata().JsonTracermutates the passed-in metadata by adding the"Message"key, so a single shared instance would throw a duplicate-key exception on the second tracer call — and the throw was being caught and reported back as a false-positive"Upgrade failed"even though the installer had launched successfully. Matches the fresh-per-call pattern used byGetActiveRepoListHandlerandRequestHandler.Testing
Manual end-to-end on Windows:
--allow-unsigned"Installer is not signed"notepad.exe(Microsoft-signed, wrongProductName)ProductNamecheck--allow-unsignedfrom non-admin shell (DEBUG build)"requires Administrator privileges""Authenticode hash does not match — file has been tampered with"SetupGVFS.2.0.26147.6.exewith mounts activePendingUpgrade\, mount stayedReadyPendingUpgradeHandlerapplies the staged upgrade within ~5s, version updates accordingly818 unit tests pass.